/********************************************************************
 * MONITOR DE GATEWAYS LoRaWAN - THE THINGS NETWORK
 * Google Sheets + Google Apps Script
 *
 * VERSION:
 * - Varios gateways
 * - Varios responsables por gateway
 * - Copia a correo central
 * - Estado real mediante TTN GatewayConnectionStats
 * - Diferencia entre fecha real TTN y primera detección del monitor
 * - Tratamiento específico de desconexiones > 48 h
 * - Alerta inicial
 * - Primer recordatorio a las 6 h
 * - Recordatorios posteriores cada 24 h
 * - Aviso de recuperación
 * - Histórico LOG_GATEWAYS
 * - Duración real solo cuando es calculable rigurosamente
 * - Errores API separados de desconexiones
 * - Función para reiniciar pruebas sin borrar gateways
 ********************************************************************/


/********************************************************************
 * CONFIGURACIÓN
 ********************************************************************/

const GW_CFG = {

  HOJA_GATEWAYS:
    'DATOS_GATEWAYS',

  HOJA_GATEWAYS_ANTIGUA:
    'GATEWAYS',

  HOJA_LOG:
    'LOG_GATEWAYS',

  API_KEY_PROPERTY:
    'TTN_API_KEY',

  SPREADSHEET_ID_PROPERTY:
    'MONITOR_SPREADSHEET_ID',


  /******************************************************************
   * CORREO CENTRAL
   *
   * Recibirá copia de todos los avisos.
   ******************************************************************/

  EMAIL_CENTRAL:
    'TU_EMAIL@MAIL.es',


  /******************************************************************
   * IDENTIFICACIÓN
   ******************************************************************/

  NOMBRE_SISTEMA:
    'Monitorización de la red LoRaWAN - TÚ ORGANIZACIÓN',


  /******************************************************************
   * CLUSTER TTN
   ******************************************************************/

  CLUSTER_DEFECTO:
    'https://eu1.cloud.thethings.network',


  /******************************************************************
   * CONFIRMACIÓN DE DESCONEXIÓN
   ******************************************************************/

  FALLOS_PARA_ALERTA:
    2,

  MINUTOS_MINIMOS_DESCONEXION:
    60,


  /******************************************************************
   * RECORDATORIOS
   ******************************************************************/

  ENVIAR_RECORDATORIOS:
    true,

  HORAS_PRIMER_RECORDATORIO:
    6,

  HORAS_RECORDATORIOS_POSTERIORES:
    24,

  /******************************************************************
   * HORARIO DE ENVÍO DE CORREOS
   *
   * Monitorización: 24 h
   * Correos: 07:00 - 21:00
  ******************************************************************/

  HORA_INICIO_AVISOS:
    7,

  HORA_FIN_AVISOS:
    21,

  /******************************************************************
   * TRIGGER
   ******************************************************************/

  INTERVALO_MINUTOS:
    5,


  /******************************************************************
   * ZONA HORARIA
   ******************************************************************/

  TIMEZONE:
    'Europe/Madrid',


  /******************************************************************
   * RECUPERACIÓN
   ******************************************************************/

  AVISAR_RECUPERACION:
    true

};


/********************************************************************
 * CAMPOS DE DATOS_GATEWAYS
 ********************************************************************/

const CAMPOS_GATEWAY_MANUALES = [

  'gateway_id',
  'nombre',
  'entidad',
  'ubicacion',
  'responsable',
  'correos_responsables',
  'activo',
  'cluster'

];


const CAMPOS_GATEWAY_AUTOMATICOS = [

  'estado_gateway',
  'ultima_comprobacion',
  'conectado_desde',

  /*
   * Solo contiene una fecha cuando TTN
   * proporciona una fecha real de desconexión.
   */
  'ultima_desconexion',

  'ultima_recuperacion',
  'ultimo_uplink',
  'ultimo_status',
  'protocolo',

  'fallos_consecutivos',

  /*
   * Inicio real de la incidencia únicamente
   * cuando es conocido.
   *
   * Para DESCONEXION_PROLONGADA puede quedar vacío.
   */
  'inicio_incidencia',

  /*
   * Valores:
   *
   * TTN
   * DESCONEXION_PROLONGADA
   * MONITOR
   */
  'origen_inicio_incidencia',

  /*
   * Momento en que ESTE monitor observó
   * por primera vez el problema.
   */
  'primera_deteccion_monitor',

  'ultimo_cambio',

  /*
   * Último correo enviado:
   * alerta inicial o recordatorio.
   */
  'ultima_alerta',

  /*
   * TRUE mientras existe una incidencia
   * comunicada y aún no recuperada.
   */
  'alerta_activa',

  'estado_api',
  'detalle_api',

  'recordatorios_enviados'

];


const CABECERAS_GATEWAYS =
  CAMPOS_GATEWAY_MANUALES.concat(
    CAMPOS_GATEWAY_AUTOMATICOS
  );


/********************************************************************
 * CABECERAS LOG
 ********************************************************************/

const CABECERAS_LOG = [

  'fecha_registro',

  'gateway_id',
  'nombre',
  'entidad',
  'ubicacion',

  'evento',
  'estado_gateway',

  /*
   * Inicio real de la incidencia, si se conoce.
   */
  'inicio_incidencia',

  /*
   * TTN / DESCONEXION_PROLONGADA / MONITOR
   */
  'origen_inicio',

  /*
   * Primera vez que nuestro monitor
   * observó el problema.
   */
  'primera_deteccion_monitor',

  'fecha_recuperacion',

  /*
   * Solo se rellena cuando la duración
   * puede calcularse con un inicio real conocido.
   */
  'duracion_min',

  /*
   * Siempre que sea posible:
   * tiempo observado por nuestro monitor.
   */
  'tiempo_desde_primera_deteccion_min',

  'detalle',

  'recordatorio_num'

];


/********************************************************************
 * MENÚ
 ********************************************************************/

function onOpen() {

  SpreadsheetApp
    .getUi()
    .createMenu(
      'Monitor gateways'
    )

    .addItem(
      'Inicializar / actualizar hojas',
      'inicializarMonitorGateways'
    )

    .addItem(
      'Reiniciar datos de prueba',
      'reiniciarPruebaMonitor'
    )

    .addSeparator()

    .addItem(
      'Probar API primer gateway',
      'probarApiPrimerGateway'
    )

    .addItem(
      'Comprobar gateways ahora',
      'comprobarGateways'
    )

    .addSeparator()

    .addItem(
      'Crear trigger automático',
      'crearTriggerMonitorGateways'
    )

    .addItem(
      'Eliminar trigger automático',
      'eliminarTriggerMonitorGateways'
    )

    .addToUi();

}


/********************************************************************
 * INICIALIZACIÓN
 ********************************************************************/

function inicializarMonitorGateways() {

  const ss =
    SpreadsheetApp.getActiveSpreadsheet();


  if (!ss) {

    throw new Error(
      'No se ha podido identificar el Google Sheet activo.'
    );

  }


  PropertiesService
    .getScriptProperties()
    .setProperty(
      GW_CFG.SPREADSHEET_ID_PROPERTY,
      ss.getId()
    );


  /******************************************************************
   * DATOS_GATEWAYS
   ******************************************************************/

  let hojaGW =
    ss.getSheetByName(
      GW_CFG.HOJA_GATEWAYS
    );


  /*
   * Compatibilidad con la primera versión.
   */
  if (!hojaGW) {

    const antigua =
      ss.getSheetByName(
        GW_CFG.HOJA_GATEWAYS_ANTIGUA
      );


    if (antigua) {

      antigua.setName(
        GW_CFG.HOJA_GATEWAYS
      );


      hojaGW =
        antigua;

    }

  }


  if (!hojaGW) {

    hojaGW =
      ss.insertSheet(
        GW_CFG.HOJA_GATEWAYS
      );

  }


  asegurarCabeceras_(
    hojaGW,
    CABECERAS_GATEWAYS
  );


  /******************************************************************
   * LOG_GATEWAYS
   ******************************************************************/

  let hojaLog =
    ss.getSheetByName(
      GW_CFG.HOJA_LOG
    );


  if (!hojaLog) {

    hojaLog =
      ss.insertSheet(
        GW_CFG.HOJA_LOG
      );

  }


  asegurarCabeceras_(
    hojaLog,
    CABECERAS_LOG
  );


  /******************************************************************
   * FORMATO
   ******************************************************************/

  hojaGW.setFrozenRows(1);
  hojaLog.setFrozenRows(1);


  hojaGW
    .getRange(
      1,
      1,
      1,
      hojaGW.getLastColumn()
    )
    .setFontWeight(
      'bold'
    );


  hojaLog
    .getRange(
      1,
      1,
      1,
      hojaLog.getLastColumn()
    )
    .setFontWeight(
      'bold'
    );


  formatearColumnasFechaGateway_(
    hojaGW
  );


  formatearColumnasFechaLog_(
    hojaLog
  );


  ss.toast(
    'Monitor inicializado / actualizado.',
    'Gateways',
    6
  );

}


/********************************************************************
 * REINICIAR UNA PRUEBA
 *
 * IMPORTANTE:
 *
 * - NO borra gateway_id
 * - NO borra nombre
 * - NO borra cooperativa
 * - NO borra ubicación
 * - NO borra responsables/correos
 * - NO borra TRUE
 * - NO borra cluster
 *
 * Borra únicamente:
 *
 * - estados automáticos
 * - contadores
 * - fechas automáticas
 * - LOG_GATEWAYS
 *
 * También elimina temporalmente el trigger.
 ********************************************************************/

function reiniciarPruebaMonitor() {

  eliminarTriggerMonitorGateways();


  const ss =
    obtenerSpreadsheet_();


  const hoja =
    ss.getSheetByName(
      GW_CFG.HOJA_GATEWAYS
    );


  if (!hoja) {

    throw new Error(
      'No existe DATOS_GATEWAYS.'
    );

  }


  asegurarCabeceras_(
    hoja,
    CABECERAS_GATEWAYS
  );


  const ultimaFila =
    hoja.getLastRow();


  if (
    ultimaFila >= 2
  ) {

    const cabeceras =
      hoja
        .getRange(
          1,
          1,
          1,
          hoja.getLastColumn()
        )
        .getDisplayValues()[0]
        .map(
          x =>
            String(x).trim()
        );


    CAMPOS_GATEWAY_AUTOMATICOS
      .forEach(
        nombre => {

          const indice =
            cabeceras.indexOf(
              nombre
            );


          if (
            indice >= 0
          ) {

            hoja
              .getRange(
                2,
                indice + 1,
                ultimaFila - 1,
                1
              )
              .clearContent();

          }

        }
      );

  }


  /******************************************************************
   * VACIAR LOG
   ******************************************************************/

  const hojaLog =
    ss.getSheetByName(
      GW_CFG.HOJA_LOG
    );


  if (
    hojaLog &&
    hojaLog.getLastRow() >= 2
  ) {

    hojaLog
      .getRange(
        2,
        1,
        hojaLog.getLastRow() - 1,
        hojaLog.getLastColumn()
      )
      .clearContent();

  }


  ss.toast(
    'Prueba reiniciada. Los gateways y correos se han conservado.',
    'Gateways',
    8
  );

}


/********************************************************************
 * FUNCIÓN PRINCIPAL
 ********************************************************************/

function comprobarGateways() {

  const lock =
    LockService.getScriptLock();


  if (
    !lock.tryLock(
      10000
    )
  ) {

    console.log(
      'Existe otra ejecución del monitor en curso.'
    );

    return;

  }


  try {

    const ss =
      obtenerSpreadsheet_();


    const hoja =
      ss.getSheetByName(
        GW_CFG.HOJA_GATEWAYS
      );


    if (!hoja) {

      throw new Error(
        'No existe DATOS_GATEWAYS.'
      );

    }


    asegurarCabeceras_(
      hoja,
      CABECERAS_GATEWAYS
    );


    /****************************************************************
     * API KEY
     ****************************************************************/

    const apiKey =
      PropertiesService
        .getScriptProperties()
        .getProperty(
          GW_CFG.API_KEY_PROPERTY
        );


    if (!apiKey) {

      throw new Error(
        'No existe TTN_API_KEY en las propiedades del script.'
      );

    }


    /****************************************************************
     * DATOS
     ****************************************************************/

    const ultimaFila =
      hoja.getLastRow();


    if (
      ultimaFila < 2
    ) {

      return;

    }


    const ultimaColumna =
      hoja.getLastColumn();


    const cabeceras =
      hoja
        .getRange(
          1,
          1,
          1,
          ultimaColumna
        )
        .getDisplayValues()[0]
        .map(
          x =>
            String(x).trim()
        );


    const col =
      crearMapaCabeceras_(
        cabeceras
      );


    const datos =
      hoja
        .getRange(
          2,
          1,
          ultimaFila - 1,
          ultimaColumna
        )
        .getValues();


    const ahora =
      new Date();


    const logs =
      [];


    /****************************************************************
     * RECORRER GATEWAYS
     ****************************************************************/

    datos.forEach(
      fila => {


        /************************************************************
         * DATOS MANUALES
         ************************************************************/

        const gatewayId =
          texto_(
            fila[col.gateway_id]
          );


        if (!gatewayId) {
          return;
        }


        if (
          !esVerdadero_(
            fila[col.activo]
          )
        ) {
          return;
        }


        const nombre =
          texto_(
            fila[col.nombre]
          ) ||
          gatewayId;


        const entidad =
          texto_(
            fila[col.entidad]
          );


        const ubicacion =
          texto_(
            fila[col.ubicacion]
          );


        const responsable =
          texto_(
            fila[col.responsable]
          );


        const correos =
          normalizarCorreos_(
            fila[
              col.correos_responsables
            ]
          );


        let cluster =
          texto_(
            fila[col.cluster]
          ) ||
          GW_CFG.CLUSTER_DEFECTO;


        cluster =
          cluster.replace(
            /\/+$/,
            ''
          );


        /************************************************************
         * ESTADO PREVIO
         ************************************************************/

        const estadoAnterior =
          texto_(
            fila[col.estado_gateway]
          ) ||
          'SIN_DATOS';


        const estadoApiAnterior =
          texto_(
            fila[col.estado_api]
          );


        let fallos =
          Number(
            fila[col.fallos_consecutivos]
          ) ||
          0;


        let inicioIncidencia =
          fechaSegura_(
            fila[col.inicio_incidencia]
          );


        let origenInicio =
          texto_(
            fila[
              col.origen_inicio_incidencia
            ]
          );


        let primeraDeteccion =
          fechaSegura_(
            fila[
              col.primera_deteccion_monitor
            ]
          );


        let alertaActiva =
          esVerdadero_(
            fila[col.alerta_activa]
          );


        let recordatorios =
          Number(
            fila[col.recordatorios_enviados]
          ) ||
          0;


        /************************************************************
         * CONSULTAR TTN
         ************************************************************/

        const resultado =
          consultarEstadoGateway_(
            gatewayId,
            cluster,
            apiKey
          );


        fila[col.ultima_comprobacion] =
          ahora;


        /************************************************************
         * API FUNCIONANDO
         ************************************************************/

        if (

          resultado.tipo ===
            'CONECTADO' ||

          resultado.tipo ===
            'DESCONECTADO'

        ) {

          fila[col.estado_api] =
            'OK';


          fila[col.detalle_api] =
            '';


          if (
            estadoApiAnterior ===
            'ERROR'
          ) {

            logs.push(
              crearLog_({

                fecha_registro:
                  ahora,

                gateway_id:
                  gatewayId,

                nombre:
                  nombre,

                entidad:
                  entidad,

                ubicacion:
                  ubicacion,

                evento:
                  'API_RECUPERADA',

                estado_gateway:
                  estadoAnterior,

                inicio_incidencia:
                  inicioIncidencia,

                origen_inicio:
                  origenInicio,

                primera_deteccion_monitor:
                  primeraDeteccion,

                detalle:
                  'La comunicación con la API de TTN vuelve a funcionar.'

              })
            );

          }

        }


        /************************************************************
         * CONECTADO
         ************************************************************/

        if (
          resultado.tipo ===
          'CONECTADO'
        ) {

          const nuevoEstado =
            'CONECTADO';


          if (
            resultado.connectedAt
          ) {

            fila[col.conectado_desde] =
              resultado.connectedAt;

          }


          if (
            resultado.lastUplink
          ) {

            fila[col.ultimo_uplink] =
              resultado.lastUplink;

          }


          if (
            resultado.lastStatus
          ) {

            fila[col.ultimo_status] =
              resultado.lastStatus;

          }


          if (
            resultado.protocol
          ) {

            fila[col.protocolo] =
              resultado.protocol;

          }


          /**********************************************************
           * RECUPERACIÓN
           **********************************************************/

          if (

            alertaActiva ||

            estadoAnterior ===
              'DESCONECTADO'

          ) {

            /*
             * Si TTN proporciona connected_at,
             * utilizamos esa fecha.
             *
             * En caso contrario, la fecha de
             * detección por Apps Script.
             */
            const fechaRecuperacion =
              resultado.connectedAt ||
              ahora;


            fila[col.ultima_recuperacion] =
              fechaRecuperacion;


            /*
             * DURACIÓN REAL:
             *
             * solo la calculamos si el origen
             * del inicio es TTN.
             */
            const duracionReal =

              origenInicio === 'TTN' &&
              inicioIncidencia

                ?

                minutosEntre_(
                  inicioIncidencia,
                  fechaRecuperacion
                )

                :

                '';


            /*
             * Tiempo observado por nuestro monitor.
             */
            const tiempoDesdeDeteccion =

              primeraDeteccion

                ?

                minutosEntre_(
                  primeraDeteccion,
                  fechaRecuperacion
                )

                :

                '';


            logs.push(
              crearLog_({

                fecha_registro:
                  ahora,

                gateway_id:
                  gatewayId,

                nombre:
                  nombre,

                entidad:
                  entidad,

                ubicacion:
                  ubicacion,

                evento:
                  'RECUPERACION',

                estado_gateway:
                  nuevoEstado,

                inicio_incidencia:
                  inicioIncidencia,

                origen_inicio:
                  origenInicio,

                primera_deteccion_monitor:
                  primeraDeteccion,

                fecha_recuperacion:
                  fechaRecuperacion,

                duracion_min:
                  duracionReal,

                tiempo_desde_primera_deteccion_min:
                  tiempoDesdeDeteccion,

                detalle:
                  crearDetalleRecuperacion_(
                    origenInicio,
                    recordatorios
                  ),

                recordatorio_num:
                  recordatorios

              })
            );


            /********************************************************
             * EMAIL RECUPERACIÓN
             ********************************************************/

            if (

              /*
              * Solo avisamos de recuperación
              * si previamente se envió una alerta.
              */
              alertaActiva

              &&

              GW_CFG.AVISAR_RECUPERACION

              &&

              estaEnHorarioAvisos_(
                ahora
              )

            ) {

              const envio =
                enviarRecuperacionGateway_({

                  nombre:
                    nombre,

                  gatewayId:
                    gatewayId,

                  entidad:
                    entidad,

                  ubicacion:
                    ubicacion,

                  responsable:
                    responsable,

                  correos:
                    correos,

                  origenInicio:
                    origenInicio,

                  fechaDesconexion:
                    inicioIncidencia,

                  primeraDeteccion:
                    primeraDeteccion,

                  fechaRecuperacion:
                    fechaRecuperacion,

                  duracionRealMin:
                    duracionReal,

                  tiempoDesdeDeteccionMin:
                    tiempoDesdeDeteccion,

                  recordatoriosEnviados:
                    recordatorios

                });


              if (!envio.ok) {

                logs.push(
                  crearLog_({

                    fecha_registro:
                      ahora,

                    gateway_id:
                      gatewayId,

                    nombre:
                      nombre,

                    entidad:
                      entidad,

                    ubicacion:
                      ubicacion,

                    evento:
                      'ERROR_EMAIL_RECUPERACION',

                    estado_gateway:
                      nuevoEstado,

                    inicio_incidencia:
                      inicioIncidencia,

                    origen_inicio:
                      origenInicio,

                    primera_deteccion_monitor:
                      primeraDeteccion,

                    fecha_recuperacion:
                      fechaRecuperacion,

                    detalle:
                      envio.error

                  })
                );

              }

            }

          }


          /**********************************************************
           * CAMBIO DE ESTADO
           **********************************************************/

          if (
            nuevoEstado !==
            estadoAnterior
          ) {

            fila[col.ultimo_cambio] =
              ahora;

          }


          /**********************************************************
           * CERRAR INCIDENCIA
           **********************************************************/

          fila[col.estado_gateway] =
            nuevoEstado;


          fila[col.fallos_consecutivos] =
            0;


          fila[col.inicio_incidencia] =
            '';


          fila[
            col.origen_inicio_incidencia
          ] =
            '';


          fila[
            col.primera_deteccion_monitor
          ] =
            '';


          fila[col.alerta_activa] =
            false;


          fila[col.recordatorios_enviados] =
            0;


          return;

        }


        /************************************************************
         * DESCONECTADO
         ************************************************************/

        if (
          resultado.tipo ===
          'DESCONECTADO'
        ) {

          /*
           * El contador solo sirve para confirmar
           * la incidencia.
           *
           * No dejamos que crezca indefinidamente.
           */
          fallos =
            Math.min(

              fallos + 1,

              GW_CFG.FALLOS_PARA_ALERTA

            );


          /**********************************************************
           * PRIMERA DETECCIÓN DEL MONITOR
           **********************************************************/

          if (
            !primeraDeteccion
          ) {

            primeraDeteccion =
              ahora;

          }


          /**********************************************************
           * DATOS TÉCNICOS
           **********************************************************/

          if (
            resultado.lastUplink
          ) {

            fila[col.ultimo_uplink] =
              resultado.lastUplink;

          }


          if (
            resultado.lastStatus
          ) {

            fila[col.ultimo_status] =
              resultado.lastStatus;

          }


          if (
            resultado.protocol
          ) {

            fila[col.protocolo] =
              resultado.protocol;

          }


          /**********************************************************
           * TIPO DE FECHA DE DESCONEXIÓN
           **********************************************************/

          if (
            resultado.disconnectedAt
          ) {

            /*
             * Caso óptimo:
             *
             * TTN conserva disconnected_at.
             */
            inicioIncidencia =
              resultado.disconnectedAt;


            origenInicio =
              'TTN';


            fila[col.ultima_desconexion] =
              resultado.disconnectedAt;

          }

          else if (
            resultado.desconexionProlongada ===
            true
          ) {

            /*
             * TTN devuelve 404 not connected.
             *
             * Si YA conocíamos una fecha TTN de
             * días anteriores, la conservamos.
             */
            if (
              origenInicio !== 'TTN'
            ) {

              /*
               * No conocemos la fecha real.
               */
              inicioIncidencia =
                '';


              origenInicio =
                'DESCONEXION_PROLONGADA';


              /*
               * ultima_desconexion queda vacía.
               * No inventamos una fecha.
               */
              fila[col.ultima_desconexion] =
                '';

            }

          }

          else {

            /*
             * Caso residual:
             *
             * sabemos que no está conectado,
             * pero no tenemos fecha exacta.
             */
            if (
              !origenInicio
            ) {

              inicioIncidencia =
                '';


              origenInicio =
                'MONITOR';

            }

          }


          /**********************************************************
           * CONFIRMACIÓN
           **********************************************************/

          const cumpleFallos =

            fallos >=
            GW_CFG.FALLOS_PARA_ALERTA;


          let cumpleTiempo =
            false;


          /*
           * Desconexión prolongada:
           * TTN ya confirma una inactividad
           * suficientemente prolongada.
           */
          if (
            resultado.desconexionProlongada ===
            true
          ) {

            cumpleTiempo =
              true;

          }

          /*
           * Tenemos fecha real TTN.
           */
          else if (
            origenInicio === 'TTN' &&
            inicioIncidencia
          ) {

            const minutosReales =
              minutosEntre_(
                inicioIncidencia,
                ahora
              );


            cumpleTiempo =

              minutosReales !== '' &&

              minutosReales >=
              GW_CFG.MINUTOS_MINIMOS_DESCONEXION;

          }

          /*
           * No conocemos la fecha exacta.
           *
           * Utilizamos el tiempo observado
           * por nuestro monitor.
           */
          else if (
            primeraDeteccion
          ) {

            const minutosObservados =
              minutosEntre_(
                primeraDeteccion,
                ahora
              );


            cumpleTiempo =

              minutosObservados !== '' &&

              minutosObservados >=
              GW_CFG.MINUTOS_MINIMOS_DESCONEXION;

          }


          const desconexionConfirmada =

            cumpleFallos &&
            cumpleTiempo;


          let nuevoEstado;


          /**********************************************************
           * POSIBLE DESCONEXIÓN
           **********************************************************/

          if (
            !desconexionConfirmada
          ) {

            nuevoEstado =
              'POSIBLE_DESCONEXION';

          }


          /**********************************************************
           * DESCONEXIÓN CONFIRMADA
           **********************************************************/

          else {

            nuevoEstado =
              'DESCONECTADO';


            /********************************************************
             * REGISTRAR INICIO UNA SOLA VEZ
             ********************************************************/

            if (
              estadoAnterior !==
              'DESCONECTADO'
            ) {

              logs.push(
                crearLog_({

                  fecha_registro:
                    ahora,

                  gateway_id:
                    gatewayId,

                  nombre:
                    nombre,

                  entidad:
                    entidad,

                  ubicacion:
                    ubicacion,

                  evento:
                    'DESCONEXION_CONFIRMADA',

                  estado_gateway:
                    nuevoEstado,

                  inicio_incidencia:
                    inicioIncidencia,

                  origen_inicio:
                    origenInicio,

                  primera_deteccion_monitor:
                    primeraDeteccion,

                  detalle:
                    crearDetalleDesconexion_(
                      origenInicio
                    )

                })
              );

            }


            /********************************************************
             * ALERTA INICIAL
             ********************************************************/

            if (
              !alertaActiva
            ) {

              /*
              * La incidencia puede estar confirmada,
              * pero solo enviamos el correo dentro
              * del horario permitido.
              */
              if (
                estaEnHorarioAvisos_(
                  ahora
                )
              ) {

                const envio =
                  enviarAlertaGateway_({

                    nombre:
                      nombre,

                    gatewayId:
                      gatewayId,

                    entidad:
                      entidad,

                    ubicacion:
                      ubicacion,

                    responsable:
                      responsable,

                    correos:
                      correos,

                    origenInicio:
                      origenInicio,

                    fechaDesconexion:
                      inicioIncidencia,

                    primeraDeteccion:
                      primeraDeteccion,

                    fechaAlerta:
                      ahora,

                    ultimoUplink:
                      resultado.lastUplink ||
                      fila[col.ultimo_uplink],

                    ultimoStatus:
                      resultado.lastStatus ||
                      fila[col.ultimo_status],

                    protocolo:
                      resultado.protocol ||
                      fila[col.protocolo],

                    fallos:
                      fallos

                  });


                if (
                  envio.ok
                ) {

                  /*
                  * IMPORTANTE:
                  * solo ponemos TRUE si el correo
                  * realmente se ha enviado.
                  */
                  alertaActiva =
                    true;


                  fila[col.ultima_alerta] =
                    ahora;


                  recordatorios =
                    0;


                  fila[col.recordatorios_enviados] =
                    0;

                }


                else {

                  logs.push(
                    crearLog_({

                      fecha_registro:
                        ahora,

                      gateway_id:
                        gatewayId,

                      nombre:
                        nombre,

                      entidad:
                        entidad,

                      ubicacion:
                        ubicacion,

                      evento:
                        'ERROR_EMAIL_ALERTA',

                      estado_gateway:
                        nuevoEstado,

                      inicio_incidencia:
                        inicioIncidencia,

                      origen_inicio:
                        origenInicio,

                      primera_deteccion_monitor:
                        primeraDeteccion,

                      detalle:
                        envio.error

                    })
                  );

                }

              }

            }


            /********************************************************
             * RECORDATORIOS
             ********************************************************/

            else if (
              GW_CFG.ENVIAR_RECORDATORIOS
            ) {

              const ultimaAlerta =
                fechaSegura_(
                  fila[col.ultima_alerta]
                );


            if (

              debeEnviarRecordatorio_(

                ultimaAlerta,

                recordatorios,

                ahora

              )

              &&

              estaEnHorarioAvisos_(
                ahora
              )

            ) {

                const numeroRecordatorio =
                  recordatorios +
                  1;


                const duracionReal =

                  origenInicio === 'TTN' &&
                  inicioIncidencia

                    ?

                    minutosEntre_(
                      inicioIncidencia,
                      ahora
                    )

                    :

                    '';


                const tiempoDesdeDeteccion =

                  primeraDeteccion

                    ?

                    minutosEntre_(
                      primeraDeteccion,
                      ahora
                    )

                    :

                    '';


                const envio =
                  enviarRecordatorioGateway_({

                    nombre:
                      nombre,

                    gatewayId:
                      gatewayId,

                    entidad:
                      entidad,

                    ubicacion:
                      ubicacion,

                    responsable:
                      responsable,

                    correos:
                      correos,

                    origenInicio:
                      origenInicio,

                    fechaDesconexion:
                      inicioIncidencia,

                    primeraDeteccion:
                      primeraDeteccion,

                    fechaRecordatorio:
                      ahora,

                    duracionRealMin:
                      duracionReal,

                    tiempoDesdeDeteccionMin:
                      tiempoDesdeDeteccion,

                    numeroRecordatorio:
                      numeroRecordatorio

                  });


                if (
                  envio.ok
                ) {

                  recordatorios =
                    numeroRecordatorio;


                  fila[col.ultima_alerta] =
                    ahora;


                  fila[col.recordatorios_enviados] =
                    recordatorios;


                  logs.push(
                    crearLog_({

                      fecha_registro:
                        ahora,

                      gateway_id:
                        gatewayId,

                      nombre:
                        nombre,

                      entidad:
                        entidad,

                      ubicacion:
                        ubicacion,

                      evento:
                        'RECORDATORIO_ENVIADO',

                      estado_gateway:
                        nuevoEstado,

                      inicio_incidencia:
                        inicioIncidencia,

                      origen_inicio:
                        origenInicio,

                      primera_deteccion_monitor:
                        primeraDeteccion,

                      duracion_min:
                        duracionReal,

                      tiempo_desde_primera_deteccion_min:
                        tiempoDesdeDeteccion,

                      detalle:
                        'El gateway continúa desconectado.',

                      recordatorio_num:
                        numeroRecordatorio

                    })
                  );

                }

                else {

                  logs.push(
                    crearLog_({

                      fecha_registro:
                        ahora,

                      gateway_id:
                        gatewayId,

                      nombre:
                        nombre,

                      entidad:
                        entidad,

                      ubicacion:
                        ubicacion,

                      evento:
                        'ERROR_EMAIL_RECORDATORIO',

                      estado_gateway:
                        nuevoEstado,

                      inicio_incidencia:
                        inicioIncidencia,

                      origen_inicio:
                        origenInicio,

                      primera_deteccion_monitor:
                        primeraDeteccion,

                      detalle:
                        envio.error,

                      recordatorio_num:
                        numeroRecordatorio

                    })
                  );

                }

              }

            }

          }


          /**********************************************************
           * CAMBIO DE ESTADO
           **********************************************************/

          if (
            nuevoEstado !==
            estadoAnterior
          ) {

            fila[col.ultimo_cambio] =
              ahora;

          }


          /**********************************************************
           * ACTUALIZAR FILA
           **********************************************************/

          fila[col.estado_gateway] =
            nuevoEstado;


          fila[col.fallos_consecutivos] =
            fallos;


          fila[col.inicio_incidencia] =
            inicioIncidencia ||
            '';


          fila[
            col.origen_inicio_incidencia
          ] =
            origenInicio;


          fila[
            col.primera_deteccion_monitor
          ] =
            primeraDeteccion;


          fila[col.alerta_activa] =
            alertaActiva;


          fila[col.recordatorios_enviados] =
            recordatorios;


          return;

        }


        /************************************************************
         * ERROR API
         ************************************************************/

        if (
          resultado.tipo ===
          'ERROR_API'
        ) {

          /*
           * No modificamos el estado del gateway.
           */
          fila[col.estado_api] =
            'ERROR';


          fila[col.detalle_api] =
            resultado.detalle ||
            '';


          if (
            estadoApiAnterior !==
            'ERROR'
          ) {

            logs.push(
              crearLog_({

                fecha_registro:
                  ahora,

                gateway_id:
                  gatewayId,

                nombre:
                  nombre,

                entidad:
                  entidad,

                ubicacion:
                  ubicacion,

                evento:
                  'ERROR_API',

                estado_gateway:
                  estadoAnterior,

                inicio_incidencia:
                  inicioIncidencia,

                origen_inicio:
                  origenInicio,

                primera_deteccion_monitor:
                  primeraDeteccion,

                detalle:
                  resultado.detalle

              })
            );

          }

        }

      }
    );


    /****************************************************************
     * ACTUALIZACIÓN EN BLOQUE
     ****************************************************************/

    hoja
      .getRange(
        2,
        1,
        datos.length,
        ultimaColumna
      )
      .setValues(
        datos
      );


    /****************************************************************
     * LOG
     ****************************************************************/

    if (
      logs.length > 0
    ) {

      guardarLogs_(
        logs
      );

    }

  }

  finally {

    lock.releaseLock();

  }

}


/********************************************************************
 * CONSULTAR TTN
 ********************************************************************/

function consultarEstadoGateway_(
  gatewayId,
  cluster,
  apiKey
) {

  const url =

    cluster +

    '/api/v3/gs/gateways/' +

    encodeURIComponent(
      gatewayId
    ) +

    '/connection/stats';


  try {

    const respuesta =
      UrlFetchApp.fetch(
        url,
        {

          method:
            'get',

          headers: {

            'Authorization':
              'Bearer ' +
              apiKey,

            'Accept':
              'application/json'

          },

          muteHttpExceptions:
            true

        }
      );


    const codigo =
      respuesta.getResponseCode();


    const cuerpo =
      respuesta.getContentText();


    /****************************************************************
     * HTTP 200
     ****************************************************************/

    if (
      codigo === 200
    ) {

      const json =
        JSON.parse(
          cuerpo
        );


      const connectedAt =
        convertirFechaTTN_(
          json.connected_at
        );


      const disconnectedAt =
        convertirFechaTTN_(
          json.disconnected_at
        );


      const lastUplink =
        convertirFechaTTN_(
          json.last_uplink_received_at
        );


      const lastStatus =
        convertirFechaTTN_(
          json.last_status_received_at
        );


      /****************************************************************
       * DESCONECTADO CON ESTADÍSTICAS
       ****************************************************************/

      if (
        disconnectedAt
      ) {

        return {

          tipo:
            'DESCONECTADO',

          sinEstadisticas:
            false,

          desconexionProlongada:
            false,

          connectedAt:
            connectedAt,

          disconnectedAt:
            disconnectedAt,

          lastUplink:
            lastUplink,

          lastStatus:
            lastStatus,

          protocol:
            json.protocol ||
            '',

          uplinkCount:
            Number(
              json.uplink_count ||
              0
            )

        };

      }


      /****************************************************************
       * CONECTADO
       ****************************************************************/

      return {

        tipo:
          'CONECTADO',

        sinEstadisticas:
          false,

        desconexionProlongada:
          false,

        connectedAt:
          connectedAt,

        disconnectedAt:
          '',

        lastUplink:
          lastUplink,

        lastStatus:
          lastStatus,

        protocol:
          json.protocol ||
          '',

        uplinkCount:
          Number(
            json.uplink_count ||
            0
          )

      };

    }


    /****************************************************************
     * ANALIZAR ERROR
     ****************************************************************/

    let errorJson =
      {};


    try {

      errorJson =
        JSON.parse(
          cuerpo
        );

    }

    catch (e) {

      // Respuesta no JSON.

    }


    const textoError =
      (
        JSON.stringify(
          errorJson
        ) +
        ' ' +
        cuerpo
      )
        .toLowerCase();


    /****************************************************************
     * 404 NOT CONNECTED
     *
     * TTN ya no conserva las estadísticas.
     ****************************************************************/

    if (

      codigo === 404 &&

      (
        textoError.includes(
          'not_connected'
        ) ||

        textoError.includes(
          'not connected'
        )
      )

    ) {

      return {

        tipo:
          'DESCONECTADO',

        sinEstadisticas:
          true,

        desconexionProlongada:
          true,

        connectedAt:
          '',

        disconnectedAt:
          '',

        lastUplink:
          '',

        lastStatus:
          '',

        protocol:
          '',

        uplinkCount:
          0

      };

    }


    /****************************************************************
     * OTROS ERRORES
     ****************************************************************/

    return {

      tipo:
        'ERROR_API',

      detalle:

        'HTTP ' +
        codigo +
        ' - ' +
        extraerMensajeError_(
          errorJson,
          cuerpo
        )

    };

  }

  catch (error) {

    return {

      tipo:
        'ERROR_API',

      detalle:
        error.toString()

    };

  }

}


/********************************************************************
 * PROBAR API
 *
 * NO modifica:
 *
 * - fallos
 * - estados
 * - alertas
 * - LOG
 ********************************************************************/

function probarApiPrimerGateway() {

  const ss =
    obtenerSpreadsheet_();


  const hoja =
    ss.getSheetByName(
      GW_CFG.HOJA_GATEWAYS
    );


  if (!hoja) {

    throw new Error(
      'No existe DATOS_GATEWAYS.'
    );

  }


  asegurarCabeceras_(
    hoja,
    CABECERAS_GATEWAYS
  );


  const apiKey =
    PropertiesService
      .getScriptProperties()
      .getProperty(
        GW_CFG.API_KEY_PROPERTY
      );


  if (!apiKey) {

    throw new Error(
      'No existe TTN_API_KEY.'
    );

  }


  if (
    hoja.getLastRow() < 2
  ) {

    throw new Error(
      'No hay gateways configurados.'
    );

  }


  const cabeceras =
    hoja
      .getRange(
        1,
        1,
        1,
        hoja.getLastColumn()
      )
      .getDisplayValues()[0]
      .map(
        x =>
          String(x).trim()
      );


  const col =
    crearMapaCabeceras_(
      cabeceras
    );


  const datos =
    hoja
      .getRange(
        2,
        1,
        hoja.getLastRow() - 1,
        hoja.getLastColumn()
      )
      .getValues();


  for (
    let i = 0;
    i < datos.length;
    i++
  ) {

    const fila =
      datos[i];


    const gatewayId =
      texto_(
        fila[col.gateway_id]
      );


    if (
      !gatewayId ||
      !esVerdadero_(
        fila[col.activo]
      )
    ) {

      continue;

    }


    const cluster =
      (
        texto_(
          fila[col.cluster]
        ) ||
        GW_CFG.CLUSTER_DEFECTO
      )
        .replace(
          /\/+$/,
          ''
        );


    console.log(
      'Probando gateway: ' +
      gatewayId
    );


    console.log(
      'Cluster: ' +
      cluster
    );


    const resultado =
      consultarEstadoGateway_(
        gatewayId,
        cluster,
        apiKey
      );


    console.log(
      JSON.stringify(
        resultado,
        null,
        2
      )
    );


    ss.toast(
      gatewayId +
      ': ' +
      resultado.tipo,
      'Prueba API TTN',
      10
    );


    return;

  }


  throw new Error(
    'No existe ningún gateway activo.'
  );

}


/********************************************************************
 * ¿CORRESPONDE RECORDATORIO?
 ********************************************************************/

function debeEnviarRecordatorio_(
  ultimaAlerta,
  recordatorios,
  ahora
) {

  if (
    !ultimaAlerta
  ) {

    return false;

  }


  const horas =
    horasEntre_(
      ultimaAlerta,
      ahora
    );


  if (
    horas === ''
  ) {

    return false;

  }


  if (
    Number(recordatorios) === 0
  ) {

    return (

      horas >=
      GW_CFG.HORAS_PRIMER_RECORDATORIO

    );

  }


  return (

    horas >=
    GW_CFG.HORAS_RECORDATORIOS_POSTERIORES

  );

}


/********************************************************************
 * EMAIL ALERTA INICIAL
 ********************************************************************/

function enviarAlertaGateway_(
  datos
) {

  const fechaAlerta =
    formatearFechaSegura_(
      datos.fechaAlerta
    );


  const primeraDeteccion =
    formatearFechaSegura_(
      datos.primeraDeteccion
    );


  const ultimoUplink =
    formatearFechaSegura_(
      datos.ultimoUplink
    );


  const ultimoStatus =
    formatearFechaSegura_(
      datos.ultimoStatus
    );


  let etiquetaPrincipal;
  let valorPrincipal;
  let notaPrincipal;


  if (
    datos.origenInicio === 'TTN'
  ) {

    etiquetaPrincipal =
      'Fecha de desconexión';


    valorPrincipal =
      formatearFechaSegura_(
        datos.fechaDesconexion
      );


    notaPrincipal =
      'Fecha registrada por The Things Network';

  }

    else if (
    datos.origenInicio ===
    'DESCONEXION_PROLONGADA'
  ) {

    etiquetaPrincipal =
      'Desconexión prolongada';


    valorPrincipal =
      'Más de 48 horas sin conexión';


    notaPrincipal =
      'The Things Network ya no conserva las estadísticas ' +
      'de la última conexión del gateway.';

  }

  else {

    etiquetaPrincipal =
      'Fecha exacta de desconexión';


    valorPrincipal =
      'No disponible';


    notaPrincipal =
      'La fecha exacta no ha podido determinarse';

  }


  const asunto =

    '⚠️ Alerta LoRaWAN | Gateway desconectado | ' +

    datos.nombre;

  let avisoDesconexionProlongada =
  '';


if (
  datos.origenInicio ===
  'DESCONEXION_PROLONGADA'
) {

  avisoDesconexionProlongada =

    'AVISO: DESCONEXIÓN PROLONGADA\n' +

    'The Things Network ya no conserva las estadísticas ' +
    'de la última conexión. Esto indica que el gateway lleva ' +
    'más de 48 horas sin conexión con TTN.\n\n';

}
  const texto =

    GW_CFG.NOMBRE_SISTEMA +
    '\n\n' +

    'Se ha confirmado la desconexión de un gateway LoRaWAN.\n\n' +

    avisoDesconexionProlongada +

'Gateway: ' +
    datos.nombre +
    '\n' +

    'Gateway ID: ' +
    datos.gatewayId +
    '\n' +

    'Entidad: ' +
    (datos.entidad || '-') +
    '\n' +

    'Ubicación: ' +
    (datos.ubicacion || '-') +
    '\n' +

    'Responsable: ' +
    (datos.responsable || '-') +
    '\n\n' +

    etiquetaPrincipal +
    ': ' +
    valorPrincipal +
    '\n' +

    'Primera detección por el monitor: ' +
    primeraDeteccion +
    '\n' +

    'Alerta confirmada: ' +
    fechaAlerta +
    '\n\n' +

    'Último uplink: ' +
    ultimoUplink +
    '\n' +

    'Último status: ' +
    ultimoStatus +
    '\n\n' +

    'La monitorización continuará automáticamente.';


  const filas =

    filaHtml_(
      'Gateway ID',
      datos.gatewayId
    ) +

    filaHtml_(
      'Entidad',
      datos.entidad || '-'
    ) +

    filaHtml_(
      'Ubicación',
      datos.ubicacion || '-'
    ) +

    filaHtml_(
      'Responsable',
      datos.responsable || '-'
    ) +

    filaHtml_(
      'Primera detección monitor',
      primeraDeteccion
    ) +

    filaHtml_(
      'Alerta confirmada',
      fechaAlerta
    ) +

    filaHtml_(
      'Último uplink',
      ultimoUplink
    ) +

    filaHtml_(
      'Último status',
      ultimoStatus
    ) +

    filaHtml_(
      'Protocolo',
      datos.protocolo || '-'
    );


  const html =
    crearHtmlCorreo_({

      color:
        '#a93226',

      titulo:
        '⚠ Gateway LoRaWAN desconectado',

      textoIntro:
        'El sistema de monitorización ha confirmado una incidencia de conexión.',

      nombre:
        datos.nombre,

      etiquetaDestacada:
        etiquetaPrincipal,

      valorDestacado:
        valorPrincipal,

      notaDestacada:
        notaPrincipal,

      filas:
        filas,

      mensajeFinal:
        'La monitorización continuará automáticamente. ' +
        'Si el gateway continúa desconectado, el primer recordatorio ' +
        'se enviará 6 horas después de esta alerta.'

    });


  return enviarCorreoGateway_(
    datos.correos,
    asunto,
    texto,
    html
  );

}


/********************************************************************
 * EMAIL RECORDATORIO
 ********************************************************************/

function enviarRecordatorioGateway_(
  datos
) {

  const primeraDeteccion =
    formatearFechaSegura_(
      datos.primeraDeteccion
    );


  let etiquetaPrincipal;
  let valorPrincipal;
  let notaPrincipal;


  if (
    datos.origenInicio === 'TTN'
  ) {

    etiquetaPrincipal =
      'Tiempo acumulado sin conexión';


    valorPrincipal =
      formatearDuracion_(
        datos.duracionRealMin
      );


    notaPrincipal =
      'Calculado desde la fecha de desconexión registrada por TTN';

  }

  else {

    etiquetaPrincipal =
      'Tiempo desde primera detección';


    valorPrincipal =
      formatearDuracion_(
        datos.tiempoDesdeDeteccionMin
      );


    notaPrincipal =
      'La duración real de la desconexión no puede calcularse';

  }


  const asunto =

    '⏰ Recordatorio LoRaWAN | Gateway sigue desconectado | ' +

    datos.nombre;


  const texto =

    GW_CFG.NOMBRE_SISTEMA +
    '\n\n' +

    'El gateway continúa desconectado.\n\n' +

    'Gateway: ' +
    datos.nombre +
    '\n' +

    'Entidad: ' +
    (datos.entidad || '-') +
    '\n' +

    'Ubicación: ' +
    (datos.ubicacion || '-') +
    '\n' +

    'Primera detección por el monitor: ' +
    primeraDeteccion +
    '\n' +

    etiquetaPrincipal +
    ': ' +
    valorPrincipal +
    '\n' +

    'Recordatorio nº: ' +
    datos.numeroRecordatorio;


  const filas =

    filaHtml_(
      'Gateway ID',
      datos.gatewayId
    ) +

    filaHtml_(
      'Entidad',
      datos.entidad || '-'
    ) +

    filaHtml_(
      'Ubicación',
      datos.ubicacion || '-'
    ) +

    filaHtml_(
      'Responsable',
      datos.responsable || '-'
    ) +

    filaHtml_(
      'Primera detección monitor',
      primeraDeteccion
    ) +

    filaHtml_(
      'Recordatorio nº',
      String(
        datos.numeroRecordatorio
      )
    );


  const html =
    crearHtmlCorreo_({

      color:
        '#b26a00',

      titulo:
        '⏰ Gateway continúa desconectado',

      textoIntro:
        'Este es un recordatorio automático de una incidencia previamente comunicada.',

      nombre:
        datos.nombre,

      etiquetaDestacada:
        etiquetaPrincipal,

      valorDestacado:
        valorPrincipal,

      notaDestacada:
        notaPrincipal,

      filas:
        filas,

      mensajeFinal:
        'El sistema continuará comprobando el gateway. ' +
        'Los siguientes recordatorios se enviarán cada 24 horas.'

    });


  return enviarCorreoGateway_(
    datos.correos,
    asunto,
    texto,
    html
  );

}


/********************************************************************
 * EMAIL RECUPERACIÓN
 ********************************************************************/

function enviarRecuperacionGateway_(
  datos
) {

  const fechaRecuperacion =
    formatearFechaSegura_(
      datos.fechaRecuperacion
    );


  const primeraDeteccion =
    formatearFechaSegura_(
      datos.primeraDeteccion
    );


  let etiquetaPrincipal;
  let valorPrincipal;
  let notaPrincipal;


  if (
    datos.origenInicio === 'TTN' &&
    datos.duracionRealMin !== ''
  ) {

    etiquetaPrincipal =
      'Duración de la incidencia';


    valorPrincipal =
      formatearDuracion_(
        datos.duracionRealMin
      );


    notaPrincipal =
      'Calculada a partir de la fecha de desconexión registrada por TTN';

  }

  else {

    etiquetaPrincipal =
      'Duración real de la desconexión';


    valorPrincipal =
      'No calculable';


    notaPrincipal =

      'Tiempo observado desde la primera detección: ' +

      formatearDuracion_(
        datos.tiempoDesdeDeteccionMin
      );

  }


  const asunto =

    '✅ Recuperación LoRaWAN | Gateway conectado | ' +

    datos.nombre;


  const texto =

    GW_CFG.NOMBRE_SISTEMA +
    '\n\n' +

    'El gateway vuelve a estar conectado.\n\n' +

    'Gateway: ' +
    datos.nombre +
    '\n' +

    'Entidad: ' +
    (datos.entidad || '-') +
    '\n' +

    'Ubicación: ' +
    (datos.ubicacion || '-') +
    '\n' +

    'Primera detección monitor: ' +
    primeraDeteccion +
    '\n' +

    'Fecha de recuperación: ' +
    fechaRecuperacion +
    '\n' +

    etiquetaPrincipal +
    ': ' +
    valorPrincipal +
    '\n\n' +

    'La incidencia queda cerrada automáticamente.';


  const filas =

    filaHtml_(
      'Gateway ID',
      datos.gatewayId
    ) +

    filaHtml_(
      'Entidad',
      datos.entidad || '-'
    ) +

    filaHtml_(
      'Ubicación',
      datos.ubicacion || '-'
    ) +

    filaHtml_(
      'Responsable',
      datos.responsable || '-'
    ) +

    filaHtml_(
      'Primera detección monitor',
      primeraDeteccion
    ) +

    filaHtml_(
      'Fecha de recuperación',
      fechaRecuperacion
    ) +

    filaHtml_(
      'Recordatorios enviados',
      String(
        datos.recordatoriosEnviados ||
        0
      )
    );


  const html =
    crearHtmlCorreo_({

      color:
        '#287a46',

      titulo:
        '✅ Gateway LoRaWAN recuperado',

      textoIntro:
        'El sistema de monitorización ha detectado que el gateway vuelve a estar conectado.',

      nombre:
        datos.nombre,

      etiquetaDestacada:
        etiquetaPrincipal,

      valorDestacado:
        valorPrincipal,

      notaDestacada:
        notaPrincipal,

      filas:
        filas,

      mensajeFinal:
        'La incidencia se considera cerrada. ' +
        'El gateway continuará siendo monitorizado automáticamente.'

    });


  return enviarCorreoGateway_(
    datos.correos,
    asunto,
    texto,
    html
  );

}


/********************************************************************
 * ENVÍO EMAIL
 ********************************************************************/

function enviarCorreoGateway_(
  correosResponsables,
  asunto,
  texto,
  html
) {

  try {

    const destinatarios =
      prepararDestinatarios_(
        correosResponsables
      );


    if (
      !destinatarios.to
    ) {

      return {

        ok:
          false,

        error:
          'No existen destinatarios configurados.'

      };

    }


    const opciones = {

      to:
        destinatarios.to,

      subject:
        asunto,

      body:
        texto,

      htmlBody:
        html,

      name:
        'Monitor GATEWAY LoRaWAN'

    };


    if (
      destinatarios.cc
    ) {

      opciones.cc =
        destinatarios.cc;

    }


    MailApp.sendEmail(
      opciones
    );


    return {

      ok:
        true,

      error:
        ''

    };

  }

  catch (error) {

    console.log(
      error.toString()
    );


    return {

      ok:
        false,

      error:
        error.toString()

    };

  }

}


/********************************************************************
 * PLANTILLA HTML
 ********************************************************************/

function crearHtmlCorreo_(
  datos
) {

  return `

  <div style="
    margin:0;
    padding:24px;
    background:#f3f5f7;
    font-family:Arial,Helvetica,sans-serif;
    color:#263238;
  ">

    <div style="
      max-width:680px;
      margin:auto;
      background:#ffffff;
      border:1px solid #e0e0e0;
      border-radius:12px;
      overflow:hidden;
    ">

      <div style="
        background:${datos.color};
        color:white;
        padding:24px 28px;
      ">

        <div style="
          font-size:13px;
          opacity:0.92;
          margin-bottom:8px;
        ">
          ${escaparHtml_(GW_CFG.NOMBRE_SISTEMA)}
        </div>

        <div style="
          font-size:23px;
          font-weight:bold;
        ">
          ${escaparHtml_(datos.titulo)}
        </div>

      </div>


      <div style="
        padding:28px;
      ">

        <p style="
          margin-top:0;
          font-size:15px;
          line-height:1.6;
        ">
          ${escaparHtml_(datos.textoIntro)}
        </p>


        <div style="
          background:#f7f8f9;
          padding:16px 18px;
          border-radius:8px;
          margin:20px 0;
          font-size:19px;
          font-weight:bold;
        ">
          ${escaparHtml_(datos.nombre)}
        </div>


        <div style="
          border-left:5px solid ${datos.color};
          background:#fafafa;
          padding:16px 18px;
          margin:22px 0;
        ">

          <div style="
            color:#666;
            font-size:13px;
          ">
            ${escaparHtml_(datos.etiquetaDestacada)}
          </div>

          <div style="
            color:${datos.color};
            font-size:21px;
            font-weight:bold;
            margin-top:5px;
          ">
            ${escaparHtml_(datos.valorDestacado)}
          </div>

          <div style="
            color:#777;
            font-size:11px;
            margin-top:6px;
          ">
            ${escaparHtml_(datos.notaDestacada || '')}
          </div>

        </div>


        <table style="
          width:100%;
          border-collapse:collapse;
          font-size:14px;
        ">
          ${datos.filas}
        </table>


        <div style="
          margin-top:24px;
          padding:16px;
          background:#f5f7f8;
          border-radius:7px;
          font-size:13px;
          line-height:1.6;
          color:#555;
        ">
          ${escaparHtml_(datos.mensajeFinal)}
        </div>

      </div>


      <div style="
        padding:14px 25px;
        background:#eceff1;
        text-align:center;
        font-size:11px;
        color:#777;
      ">

        Aviso generado automáticamente por el sistema
        de monitorización de infraestructura LoRaWAN.

      </div>

    </div>

  </div>

  `;

}


/********************************************************************
 * LOG
 *
 * IMPORTANTE:
 *
 * Se escribe POR NOMBRE DE CABECERA.
 *
 * Esto evita problemas si en una versión futura
 * añadimos columnas nuevas.
 ********************************************************************/

function guardarLogs_(
  logs
) {

  if (
    !logs ||
    logs.length === 0
  ) {

    return;

  }


  const ss =
    obtenerSpreadsheet_();


  let hoja =
    ss.getSheetByName(
      GW_CFG.HOJA_LOG
    );


  if (!hoja) {

    hoja =
      ss.insertSheet(
        GW_CFG.HOJA_LOG
      );

  }


  asegurarCabeceras_(
    hoja,
    CABECERAS_LOG
  );


  const cabeceras =
    hoja
      .getRange(
        1,
        1,
        1,
        hoja.getLastColumn()
      )
      .getDisplayValues()[0]
      .map(
        x =>
          String(x).trim()
      );


  const filas =
    logs.map(
      registro =>

        cabeceras.map(
          cabecera =>

            Object.prototype.hasOwnProperty.call(
              registro,
              cabecera
            )

              ?

              registro[cabecera]

              :

              ''

        )

    );


  hoja
    .getRange(
      hoja.getLastRow() + 1,
      1,
      filas.length,
      cabeceras.length
    )
    .setValues(
      filas
    );

}


/********************************************************************
 * CREAR LOG
 ********************************************************************/

function crearLog_(
  datos
) {

  return datos;

}


/********************************************************************
 * DETALLE DE DESCONEXIÓN
 ********************************************************************/

function crearDetalleDesconexion_(
  origen
) {

  if (
    origen === 'TTN'
  ) {

    return (
      'Fecha de desconexión proporcionada por The Things Network.'
    );

  }


  if (
    origen ===
    'DESCONEXION_PROLONGADA'
  ) {

    return (
      'Fecha real de desconexión no disponible. ' +
      'TTN ya no conserva las estadísticas de la última conexión. ' +
      'Se registra por separado la primera detección realizada por el monitor.'
    );

  }


  return (
    'Fecha real de desconexión no disponible. ' +
    'Se registra la primera detección realizada por el monitor.'
  );

}


/********************************************************************
 * DETALLE RECUPERACIÓN
 ********************************************************************/

function crearDetalleRecuperacion_(
  origen,
  recordatorios
) {

  if (
    origen === 'TTN'
  ) {

    return (
      'Incidencia cerrada. ' +
      'La duración se calcula a partir de la fecha de desconexión registrada por TTN. ' +
      'Recordatorios enviados: ' +
      recordatorios +
      '.'
    );

  }


  return (
    'Incidencia cerrada. ' +
    'La duración real de la desconexión no puede determinarse. ' +
    'Se conserva únicamente el tiempo transcurrido desde la primera detección del monitor. ' +
    'Recordatorios enviados: ' +
    recordatorios +
    '.'
  );

}


/********************************************************************
 * DESTINATARIOS
 ********************************************************************/

function prepararDestinatarios_(
  correosResponsables
) {

  const responsables =
    normalizarCorreos_(
      correosResponsables
    );


  const central =
    normalizarCorreos_(
      GW_CFG.EMAIL_CENTRAL
    );


  if (
    responsables
  ) {

    let cc =
      '';


    if (

      central &&

      !listasCompartenCorreo_(
        responsables,
        central
      )

    ) {

      cc =
        central;

    }


    return {

      to:
        responsables,

      cc:
        cc

    };

  }


  return {

    to:
      central,

    cc:
      ''

  };

}


/********************************************************************
 * NORMALIZAR CORREOS
 *
 * También funciona si accidentalmente se pega:
 *
 * mailto:correo@...
 *
 * o texto con varios emails.
 ********************************************************************/

function normalizarCorreos_(
  valor
) {

  if (!valor) {

    return '';

  }


  const encontrados =
    String(valor)
      .match(
        /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi
      );


  if (
    !encontrados
  ) {

    return '';

  }


  const unicos =
    [
      ...new Set(
        encontrados.map(
          correo =>
            correo.toLowerCase()
        )
      )
    ];


  return unicos.join(
    ','
  );

}


/********************************************************************
 * LISTAS COMPARTEN EMAIL
 ********************************************************************/

function listasCompartenCorreo_(
  listaA,
  listaB
) {

  const a =
    String(listaA)
      .toLowerCase()
      .split(',')
      .map(
        x =>
          x.trim()
      );


  const b =
    String(listaB)
      .toLowerCase()
      .split(',')
      .map(
        x =>
          x.trim()
      );


  return a.some(
    correo =>
      b.includes(
        correo
      )
  );

}


/********************************************************************
 * CREAR TRIGGER
 ********************************************************************/

function crearTriggerMonitorGateways() {

  const ss =
    SpreadsheetApp.getActiveSpreadsheet();


  if (ss) {

    PropertiesService
      .getScriptProperties()
      .setProperty(
        GW_CFG.SPREADSHEET_ID_PROPERTY,
        ss.getId()
      );

  }


  const permitidos =
    [
      1,
      5,
      10,
      15,
      30
    ];


  if (
    !permitidos.includes(
      GW_CFG.INTERVALO_MINUTOS
    )
  ) {

    throw new Error(
      'INTERVALO_MINUTOS debe ser 1, 5, 10, 15 o 30.'
    );

  }


  eliminarTriggerMonitorGateways();


  ScriptApp
    .newTrigger(
      'comprobarGateways'
    )
    .timeBased()
    .everyMinutes(
      GW_CFG.INTERVALO_MINUTOS
    )
    .create();


  if (ss) {

    ss.toast(
      'Monitor activado cada ' +
      GW_CFG.INTERVALO_MINUTOS +
      ' minutos.',
      'Gateways',
      6
    );

  }

}


/********************************************************************
 * ELIMINAR TRIGGER
 ********************************************************************/

function eliminarTriggerMonitorGateways() {

  ScriptApp
    .getProjectTriggers()

    .filter(
      trigger =>
        trigger.getHandlerFunction() ===
        'comprobarGateways'
    )

    .forEach(
      trigger =>
        ScriptApp.deleteTrigger(
          trigger
        )
    );

}

/********************************************************************
 * COMPROBAR SI SE PUEDEN ENVIAR CORREOS
 *
 * TRUE  -> entre 07:00 y 20:59
 * FALSE -> entre 21:00 y 06:59
 *
 * Se utiliza la hora de Europe/Madrid
 * definida en GW_CFG.TIMEZONE.
 ********************************************************************/

function estaEnHorarioAvisos_(
  fecha
) {

  const hora =
    Number(
      Utilities.formatDate(
        fecha,
        GW_CFG.TIMEZONE,
        'H'
      )
    );


  return (

    hora >=
      GW_CFG.HORA_INICIO_AVISOS

    &&

    hora <
      GW_CFG.HORA_FIN_AVISOS

  );

}

/********************************************************************
 * OBTENER SPREADSHEET
 ********************************************************************/

function obtenerSpreadsheet_() {

  const propiedades =
    PropertiesService
      .getScriptProperties();


  const spreadsheetId =
    propiedades.getProperty(
      GW_CFG.SPREADSHEET_ID_PROPERTY
    );


  if (
    spreadsheetId
  ) {

    return SpreadsheetApp.openById(
      spreadsheetId
    );

  }


  const ss =
    SpreadsheetApp.getActiveSpreadsheet();


  if (!ss) {

    throw new Error(
      'No se ha podido identificar el spreadsheet. ' +
      'Ejecuta inicializarMonitorGateways() manualmente.'
    );

  }


  propiedades.setProperty(
    GW_CFG.SPREADSHEET_ID_PROPERTY,
    ss.getId()
  );


  return ss;

}


/********************************************************************
 * ASEGURAR CABECERAS
 ********************************************************************/

function asegurarCabeceras_(
  hoja,
  cabecerasNecesarias
) {

  if (

    hoja.getLastRow() === 0 ||

    hoja.getLastColumn() === 0

  ) {

    hoja
      .getRange(
        1,
        1,
        1,
        cabecerasNecesarias.length
      )
      .setValues(
        [
          cabecerasNecesarias
        ]
      );


    return;

  }


  const actuales =
    hoja
      .getRange(
        1,
        1,
        1,
        hoja.getLastColumn()
      )
      .getDisplayValues()[0]
      .map(
        x =>
          String(x).trim()
      );


  const faltantes =
    cabecerasNecesarias.filter(
      nombre =>
        !actuales.includes(
          nombre
        )
    );


  if (
    faltantes.length > 0
  ) {

    hoja
      .getRange(
        1,
        hoja.getLastColumn() + 1,
        1,
        faltantes.length
      )
      .setValues(
        [
          faltantes
        ]
      );

  }

}


/********************************************************************
 * MAPA DE CABECERAS
 ********************************************************************/

function crearMapaCabeceras_(
  cabeceras
) {

  const mapa =
    {};


  cabeceras.forEach(
    (nombre, indice) => {

      mapa[
        String(nombre).trim()
      ] =
        indice;

    }
  );


  CABECERAS_GATEWAYS.forEach(
    nombre => {

      if (
        mapa[nombre] === undefined
      ) {

        throw new Error(
          'Falta la columna "' +
          nombre +
          '".'
        );

      }

    }
  );


  return mapa;

}


/********************************************************************
 * FORMATEAR COLUMNAS FECHA - DATOS_GATEWAYS
 ********************************************************************/

function formatearColumnasFechaGateway_(
  hoja
) {

  formatearColumnasPorNombre_(
    hoja,
    [

      'ultima_comprobacion',
      'conectado_desde',
      'ultima_desconexion',
      'ultima_recuperacion',
      'ultimo_uplink',
      'ultimo_status',
      'inicio_incidencia',
      'primera_deteccion_monitor',
      'ultimo_cambio',
      'ultima_alerta'

    ]
  );

}


/********************************************************************
 * FORMATEAR COLUMNAS FECHA - LOG
 ********************************************************************/

function formatearColumnasFechaLog_(
  hoja
) {

  formatearColumnasPorNombre_(
    hoja,
    [

      'fecha_registro',
      'inicio_incidencia',
      'primera_deteccion_monitor',
      'fecha_recuperacion'

    ]
  );

}


/********************************************************************
 * FORMATO FECHAS POR NOMBRE
 ********************************************************************/

function formatearColumnasPorNombre_(
  hoja,
  nombres
) {

  const cabeceras =
    hoja
      .getRange(
        1,
        1,
        1,
        hoja.getLastColumn()
      )
      .getDisplayValues()[0]
      .map(
        x =>
          String(x).trim()
      );


  nombres.forEach(
    nombre => {

      const indice =
        cabeceras.indexOf(
          nombre
        );


      if (
        indice >= 0
      ) {

        hoja
          .getRange(
            2,
            indice + 1,
            Math.max(
              hoja.getMaxRows() - 1,
              1
            ),
            1
          )
          .setNumberFormat(
            'dd/MM/yyyy HH:mm:ss'
          );

      }

    }
  );

}


/********************************************************************
 * FECHA TTN
 ********************************************************************/

function convertirFechaTTN_(
  valor
) {

  if (!valor) {

    return '';

  }


  if (
    String(valor).startsWith(
      '0001-01-01'
    )
  ) {

    return '';

  }


  const fecha =
    new Date(
      valor
    );


  if (
    isNaN(
      fecha.getTime()
    )
  ) {

    return '';

  }


  return fecha;

}


/********************************************************************
 * FECHA SEGURA
 ********************************************************************/

function fechaSegura_(
  valor
) {

  if (!valor) {

    return '';

  }


  if (
    valor instanceof Date
  ) {

    return isNaN(
      valor.getTime()
    )
      ?
      ''
      :
      valor;

  }


  const fecha =
    new Date(
      valor
    );


  return isNaN(
    fecha.getTime()
  )
    ?
    ''
    :
    fecha;

}


/********************************************************************
 * MINUTOS ENTRE FECHAS
 ********************************************************************/

function minutosEntre_(
  inicio,
  fin
) {

  const a =
    fechaSegura_(
      inicio
    );


  const b =
    fechaSegura_(
      fin
    );


  if (
    !a ||
    !b
  ) {

    return '';

  }


  return Math.max(
    0,
    Math.round(
      (
        b.getTime() -
        a.getTime()
      ) /
      60000
    )
  );

}


/********************************************************************
 * HORAS ENTRE FECHAS
 ********************************************************************/

function horasEntre_(
  inicio,
  fin
) {

  const a =
    fechaSegura_(
      inicio
    );


  const b =
    fechaSegura_(
      fin
    );


  if (
    !a ||
    !b
  ) {

    return '';

  }


  return Math.max(
    0,
    (
      b.getTime() -
      a.getTime()
    ) /
    3600000
  );

}


/********************************************************************
 * DURACIÓN LEGIBLE
 ********************************************************************/

function formatearDuracion_(
  minutos
) {

  if (

    minutos === '' ||

    minutos === null ||

    minutos === undefined ||

    isNaN(
      Number(minutos)
    )

  ) {

    return 'No disponible';

  }


  minutos =
    Math.round(
      Number(minutos)
    );


  if (
    minutos < 60
  ) {

    return minutos +
      ' min';

  }


  const horas =
    Math.floor(
      minutos / 60
    );


  const restoMin =
    minutos % 60;


  if (
    horas < 24
  ) {

    return horas +
      ' h ' +
      restoMin +
      ' min';

  }


  const dias =
    Math.floor(
      horas / 24
    );


  const restoHoras =
    horas % 24;


  return dias +
    ' d ' +
    restoHoras +
    ' h ' +
    restoMin +
    ' min';

}


/********************************************************************
 * FORMATEAR FECHA
 ********************************************************************/

function formatearFecha_(
  fecha
) {

  return Utilities.formatDate(

    fecha,

    GW_CFG.TIMEZONE,

    'dd/MM/yyyy HH:mm:ss'

  );

}


/********************************************************************
 * FORMATEAR FECHA SEGURA
 ********************************************************************/

function formatearFechaSegura_(
  valor
) {

  const fecha =
    fechaSegura_(
      valor
    );


  if (!fecha) {

    return 'No disponible';

  }


  return formatearFecha_(
    fecha
  );

}


/********************************************************************
 * TRUE / FALSE
 ********************************************************************/

function esVerdadero_(
  valor
) {

  if (
    valor === true
  ) {

    return true;

  }


  const texto =
    String(valor)
      .trim()
      .toUpperCase();


  return (

    texto === 'TRUE' ||

    texto === 'VERDADERO' ||

    texto === 'SI' ||

    texto === 'SÍ' ||

    texto === '1'

  );

}


/********************************************************************
 * TEXTO
 ********************************************************************/

function texto_(
  valor
) {

  if (

    valor === null ||

    valor === undefined

  ) {

    return '';

  }


  return String(
    valor
  ).trim();

}


/********************************************************************
 * ERROR API
 ********************************************************************/

function extraerMensajeError_(
  json,
  textoOriginal
) {

  if (
    json &&
    json.message
  ) {

    return String(
      json.message
    ).substring(
      0,
      500
    );

  }


  if (
    json &&
    json.message_format
  ) {

    return String(
      json.message_format
    ).substring(
      0,
      500
    );

  }


  return String(
    textoOriginal ||
    ''
  ).substring(
    0,
    500
  );

}


/********************************************************************
 * HTML SEGURO
 ********************************************************************/

function escaparHtml_(
  texto
) {

  return String(

    texto === null ||

    texto === undefined

      ?
      ''

      :
      texto

  )

    .replace(
      /&/g,
      '&amp;'
    )

    .replace(
      /</g,
      '&lt;'
    )

    .replace(
      />/g,
      '&gt;'
    )

    .replace(
      /"/g,
      '&quot;'
    )

    .replace(
      /'/g,
      '&#039;'
    );

}


/********************************************************************
 * FILA HTML
 ********************************************************************/

function filaHtml_(
  etiqueta,
  valor
) {

  return `

  <tr>

    <td style="
      padding:8px 12px 8px 0;
      color:#666;
      width:42%;
      vertical-align:top;
      border-bottom:1px solid #eeeeee;
    ">
      ${escaparHtml_(etiqueta)}
    </td>

    <td style="
      padding:8px 0;
      font-weight:600;
      vertical-align:top;
      border-bottom:1px solid #eeeeee;
    ">
      ${escaparHtml_(valor)}
    </td>

  </tr>

  `;

}
